package buildinjava;

import buildinjava.Project.ClassPath;
import buildinjava.io.Copy;
import buildinjava.io.FileFilterBuilder;
import buildinjava.io.FileFilters;
import buildinjava.io.Files;
import buildinjava.io.WildcardFileFilter;
import buildinjava.java.JavaCompiler;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Collections;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Compiles and runs the build files.
 *
 * @author Gili Tzabari
 */
public final class Build
{
	private final Logger log = LoggerFactory.getLogger(Build.class);
	private final BuildInJava buildInJava;
	private final File projectDirectory;
	private final File buildDirectory;

	/**
	 * Creates a new Build.
	 *
	 * @param buildInJavaDirectory the buildinjava installation directory
	 * @param projectDirectory the project directory
	 * @throws IllegalArgumentException if buildinjava or projectDirectory are null, or if they are not
	 * directories, or if the <code>projectDirectory/buildinjava</code> directory is missing
	 */
	public Build(File buildInJavaDirectory, File projectDirectory) throws IllegalArgumentException
	{
		if (buildInJavaDirectory == null)
			throw new IllegalArgumentException("buildinjava may not be null");
		if (!buildInJavaDirectory.isDirectory())
			throw new IllegalArgumentException(buildInJavaDirectory.getAbsolutePath()
																				 + " must point to an "
																				 + "existing directory");
		this.buildInJava = new BuildInJava(buildInJavaDirectory);

		if (projectDirectory == null)
			throw new IllegalArgumentException("projectDirectory may not be null");
		if (!projectDirectory.isDirectory())
			throw new IllegalArgumentException(projectDirectory.getAbsolutePath() + " is not a directory");
		this.projectDirectory = projectDirectory;

		this.buildDirectory = new File(projectDirectory, "buildinjava");
		if (!buildDirectory.isDirectory())
			throw new IllegalArgumentException(buildDirectory.getAbsolutePath() + " is not a directory");
	}

	/**
	 * Returns the build directory.
	 *
	 * @return the build directory
	 */
	public File getBuildDirectory()
	{
		return buildDirectory;
	}

	/**
	 * Compiles the build classpath.
	 *
	 * @param buildDirectory the directory containing the build files
	 * @throws IOException if the target directory cannot be created
	 * @throws BuildException if the Java files cannot be compiled
	 */
	private void compileClassPath() throws IOException
	{
		File source = new File(buildDirectory, "classpath");
		File target = new File(buildDirectory, "target/classpath");
		if (log.isDebugEnabled())
			log.debug("source=" + source + ", target=" + target);
		if (!target.exists() && !target.mkdirs())
			throw new BuildException(new IOException("Cannot create " + target.getAbsolutePath()));
		List<File> sourceFiles = Files.list(source, FileFilters.isDirectory(), new WildcardFileFilter(
			"*.java"));
		new JavaCompiler().classPath(buildInJava.getClassPath()).apply(sourceFiles, target, Collections.
			singleton(source));
		copyDataFiles(source, target);
	}

	/**
	 * Copies any data files that reside in the source-code directory.
	 *
	 * @param source the source-code directory
	 * @param target the target directory
	 */
	private void copyDataFiles(File source, File target)
	{
		new Copy().filter(FileFilters.isDirectory(), new FileFilterBuilder(FileFilters.isFile()).
			removeFile("*.java").build()).fromDirectory(source).toDirectory(target);
	}

	/**
	 * Compiles the build scripts.
	 *
	 * @throws IOException if the target directory cannot be created
	 * @throws BuildException if the scripts cannot be compiled
	 */
	private void compileScripts() throws BuildException
	{
		File source = new File(buildDirectory, "source");
		File target = new File(buildDirectory, "target/project");
		if (log.isDebugEnabled())
			log.debug("source=" + source + ", target=" + target);
		if (!target.exists() && !target.mkdirs())
			throw new BuildException(new IOException("Cannot create " + target.getAbsolutePath()));

		File defaultPackage = new File(buildDirectory, "target/classpath");
		List<URL> buildClassPath = Lists.newArrayList(buildInJava.getClassPath());
		buildClassPath.add(Files.toURL().apply(defaultPackage));
		URLClassLoader buildLoader = URLClassLoader.newInstance(buildClassPath.toArray(new URL[0]),
			getClass().getClassLoader());

		ClassPath classPath = getProjectClassPath(defaultPackage, buildLoader);
		List<URL> compileClassPath = classPath.getCompileClasspath(buildInJava, projectDirectory);
		if (!compileClassPath.contains(Files.toURL().apply(target)))
		{
			throw new IllegalArgumentException(classPath.getClass().getName()
																				 + ".getCompileClassPath() must contain " + target
																				 + ". Actual value: " + compileClassPath);
		}
		new JavaCompiler().classPath(compileClassPath).apply(
			Files.list(source, FileFilters.isDirectory(), new WildcardFileFilter("*.java")), target,
			ImmutableList.<File>of());
		copyDataFiles(source, target);
	}

	/**
	 * Validates project invarients.
	 */
	private void assertProjectInvarients(Project project)
	{
		if (project == null)
			throw new IllegalArgumentException("project may not be null");
		for (String name: project.getConfigurations())
		{
			try
			{
				project.getConfigurationByName(name);
			}
			catch (IllegalArgumentException e)
			{
				throw new BuildException("Project.getConfigurations() returned \"" + name + "\" but "
																 + "Project.getConfigurationByName(String name) threw an exception",
					e);
			}
		}
	}

	/**
	 * Returns the build project.
	 *
	 * @return null if no project was found
	 */
	private Project getProject()
	{
		try
		{
			List<URL> buildClassPath = Lists.newArrayList(buildInJava.getClassPath());
			File classPathRoot = new File(buildDirectory, "target/classpath");
			buildClassPath.add(classPathRoot.toURI().toURL());
			URLClassLoader buildLoader = URLClassLoader.newInstance(buildClassPath.toArray(new URL[0]),
				getClass().getClassLoader());
			Project.ClassPath projectClassPath = getProjectClassPath(classPathRoot, buildLoader);
			if (projectClassPath == null)
				return null;
			try
			{
				buildClassPath.clear();
				buildClassPath.addAll(projectClassPath.getCompileClasspath(buildInJava, projectDirectory));
				File projectRoot = new File(buildDirectory, "target/project");
				buildClassPath.add(Files.toURL().apply(projectRoot));
				buildLoader = URLClassLoader.newInstance(buildClassPath.toArray(new URL[0]),
					getClass().getClassLoader());
				Class<? extends Project.Builder> projectBuilder =
					getProjectBuilder(projectRoot, buildLoader);
				if (projectBuilder == null)
					return null;
				return projectBuilder.newInstance().create(projectDirectory);
			}
			catch (InstantiationException e)
			{
				throw new RuntimeException(e);
			}
			catch (IllegalAccessException e)
			{
				throw new RuntimeException(e);
			}
		}
		catch (MalformedURLException e)
		{
			throw new AssertionError(e);
		}
	}

	/**
	 * Returns an instance of the first class implementing Project.ClassPath.
	 *
	 * @param defaultPackage the directory corresponding to the default package
	 * @param loader the class loader to use for loading Project.ClassPath
	 * @return an instance of the first class implementing Project.ClassPath
	 * @throws BuildException if the project classpath could not be generated
	 */
	private Project.ClassPath getProjectClassPath(File defaultPackage, ClassLoader loader)
		throws BuildException
	{
		// Try loading all classes in the root package and return the first Project.Builder found
		assert (loader != null);
		for (File file: Files.list(defaultPackage, FileFilters.isDirectory(),
			new WildcardFileFilter("*.class")))
		{
			try
			{
				String className = Files.getClassName(file, defaultPackage);
				Class<?> candidate = loader.loadClass(className);
				if (Project.ClassPath.class.isAssignableFrom(candidate))
					return candidate.asSubclass(Project.ClassPath.class).newInstance();
			}
			catch (ClassNotFoundException e)
			{
				assert (false): file.getAbsolutePath() + " deleted in the middle of an operation";
				log.warn("", e);
				continue;
			}
			catch (InstantiationException e)
			{
				throw new BuildException(e);
			}
			catch (IllegalAccessException e)
			{
				throw new BuildException(e);
			}
			catch (IOException e)
			{
				throw new BuildException(e);
			}
		}
		throw new BuildException("Project.ClassPath not found at " + defaultPackage.getAbsolutePath());
	}

	/**
	 * Returns the first class implementing Project.Builder.
	 *
	 * @param defaultPackage the directory corresponding to the default package
	 * @param loader the class loader to use for loading Project.Builder
	 * @return null if no match is found
	 * @throws BuildException if an expected build error occurs
	 */
	private Class<? extends Project.Builder> getProjectBuilder(File defaultPackage, ClassLoader loader)
		throws BuildException
	{
		// Try loading all classes in the root package and return the first Project.Builder found
		for (File file: Files.list(defaultPackage, FileFilters.isDirectory(),
			new WildcardFileFilter("*.class")))
		{
			try
			{
				String className = Files.getClassName(file, defaultPackage);
				Class<?> candidate = loader.loadClass(className);
				if (Project.Builder.class.isAssignableFrom(candidate))
					return candidate.asSubclass(Project.Builder.class);
			}
			catch (ClassNotFoundException e)
			{
				assert (false): file.getAbsolutePath() + " deleted in the middle of an operation";
				log.warn("", e);
				continue;
			}
			catch (IOException e)
			{
				throw new BuildException(e);
			}
		}
		return null;
	}

	/**
	 * Builds and returns a project instance.
	 *
	 * @return null if no project was found
	 */
	public Project buildProject()
	{
		try
		{
			compileClassPath();
		}
		catch (IOException e)
		{
			throw new BuildException(e);
		}
		compileScripts();
		Project result = getProject();
		assertProjectInvarients(result);
		return result;
	}

	/**
	 * The main entry point.
	 *
	 * @param args the command-line arguments
	 */
	public static void main(String[] args)
	{
		if (args.length != 2)
		{
			System.err.println("Build <buildinjava installation directory> <configuration>");
			System.exit(1);
		}
		File projectPath = new File(System.getProperty("user.dir"));
		Build build = new Build(new File(args[0]), projectPath);
		Project project;
		try
		{
			project = build.buildProject();
		}
		catch (BuildException e)
		{
			e.printStackTrace();
			System.exit(4);
			return; // WORKAROUND: http://bugs.sun.com/view_bug.do?bug_id=6914112
		}
		if (project == null)
		{
			System.err.println("buildinjava.Project.Builder not found at " + new File(projectPath,
				"buildinjava/source").getAbsolutePath());
			System.exit(2);
		}
		Configuration configuration = project.getConfigurationByName(args[1]);
		if (configuration == null)
		{
			System.err.println("Configuration " + args[1] + " not found");
			System.exit(3);
		}
		System.err.println("Building " + args[1] + "...");
		configuration.build();
	}
}
